package org.robolectric.res.builder;

import android.content.res.Resources;
import android.content.res.XmlResourceParser;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.util.Arrays;
import java.util.List;
import org.robolectric.res.Attribute;
import org.robolectric.res.Fs;
import org.robolectric.res.FsFile;
import org.robolectric.res.ResName;
import org.robolectric.res.ResourceIndex;
import org.robolectric.res.XmlBlockLoader;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.xmlpull.v1.XmlPullParserException;

public class ResourceParser {
  /**
   * All the parser features currently supported by Android.
   */
  public static final String[] AVAILABLE_FEATURES = {
      XmlResourceParser.FEATURE_PROCESS_NAMESPACES,
      XmlResourceParser.FEATURE_REPORT_NAMESPACE_ATTRIBUTES
  };
  /**
   * All the parser features currently NOT supported by Android.
   */
  public static final String[] UNAVAILABLE_FEATURES = {
      XmlResourceParser.FEATURE_PROCESS_DOCDECL,
      XmlResourceParser.FEATURE_VALIDATION
  };

  public static XmlResourceParser from(XmlBlock block, String applicationPackageName, ResourceIndex resourceIndex) {
    return new XmlResourceParserImpl(block.getDocument(), block.getFilename(), block.getPackageName(),
        applicationPackageName, resourceIndex);
  }

  public static XmlResourceParser create(String file, String packageName, String applicationPackageName, ResourceIndex resourceIndex) {
    FsFile fsFile = Fs.fileFromPath(file);
    Document document = new XmlBlockLoader(null, "xml").parse(fsFile);
    if (document == null) {
      throw new Resources.NotFoundException("couldn't find resource " + fsFile.getPath());
    }
    XmlBlock block = XmlBlock.create(document, file, packageName);
    return from(block, applicationPackageName, resourceIndex);
  }

  /**
   * Tell is a given feature is supported by android.
   *
   * @param name Feature name.
   * @return True if the feature is supported.
   */
  public static boolean isAndroidSupportedFeature(String name) {
    if (name == null) {
      return false;
    }
    for (String feature : AVAILABLE_FEATURES) {
      if (feature.equals(name)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Concrete implementation of the {@link XmlResourceParser}.
   *
   * <p>Clients expects a pull parser while the resource loader
   * initialise this object with a {@link Document}.
   * This implementation navigates the dom and emulates a pull
   * parser by raising all the opportune events.
   *
   * <p>Note that the original android implementation is based on
   * a set of native methods calls. Here those methods are
   * re-implemented in java when possible.
   */
  public static class XmlResourceParserImpl
      implements XmlResourceParser {

    private static final ResName FAKE_RES_NAME = new ResName("_robolectric_", "attr", "_fake_");

    private final Document document;
    private final String fileName;
    private final String packageName;
    private final String applicationPackageName;
    private final ResourceIndex resourceIndex;
    private final String applicationNamespace;

    private Node currentNode;

    private boolean mStarted = false;
    private boolean mDecNextDepth = false;
    private int mDepth = 0;
    private int mEventType = START_DOCUMENT;

    public XmlResourceParserImpl(Document document, String fileName, String packageName,
        String applicationPackageName, ResourceIndex resourceIndex) {
      this.document = document;
      this.fileName = fileName;
      this.packageName = packageName;
      this.applicationPackageName = applicationPackageName;
      this.resourceIndex = resourceIndex;
      applicationNamespace = Attribute.ANDROID_RES_NS_PREFIX + applicationPackageName;
    }

    @Override
    public void setFeature(String name, boolean state)
        throws XmlPullParserException {
      if (isAndroidSupportedFeature(name) && state) {
        return;
      }
      throw new XmlPullParserException("Unsupported feature: " + name);
    }

    @Override
    public boolean getFeature(String name) {
      return isAndroidSupportedFeature(name);
    }

    @Override
    public void setProperty(String name, Object value)
        throws XmlPullParserException {
      throw new XmlPullParserException("setProperty() not supported");
    }

    @Override
    public Object getProperty(String name) {
      // Properties are not supported. Android returns null
      // instead of throwing an XmlPullParserException.
      return null;
    }

    @Override
    public void setInput(Reader in) throws XmlPullParserException {
      throw new XmlPullParserException("setInput() not supported");
    }

    @Override
    public void setInput(InputStream inputStream, String inputEncoding)
        throws XmlPullParserException {
      throw new XmlPullParserException("setInput() not supported");
    }

    @Override
    public void defineEntityReplacementText(
        String entityName, String replacementText)
        throws XmlPullParserException {
      throw new XmlPullParserException(
          "defineEntityReplacementText() not supported");
    }

    @Override
    public String getNamespacePrefix(int pos)
        throws XmlPullParserException {
      throw new XmlPullParserException(
          "getNamespacePrefix() not supported");
    }

    @Override
    public String getInputEncoding() {
      return null;
    }

    @Override
    public String getNamespace(String prefix) {
      throw new RuntimeException(
          "getNamespaceCount() not supported");
    }

    @Override
    public int getNamespaceCount(int depth)
        throws XmlPullParserException {
      throw new XmlPullParserException(
          "getNamespaceCount() not supported");
    }

    @Override
    public String getPositionDescription() {
      return "XML file " + fileName + " line #" + getLineNumber() + " (sorry, not yet implemented)";
    }

    @Override
    public String getNamespaceUri(int pos)
        throws XmlPullParserException {
      throw new XmlPullParserException(
          "getNamespaceUri() not supported");
    }

    @Override
    public int getColumnNumber() {
      // Android always returns -1
      return -1;
    }

    @Override
    public int getDepth() {
      return mDepth;
    }

    @Override
    public String getText() {
      if (currentNode == null) {
        return "";
      }
      return currentNode.getTextContent();
    }

    @Override
    public int getLineNumber() {
      // TODO(msama): The current implementation is
      //   unable to return line numbers.
      return -1;
    }

    @Override
    public int getEventType()
        throws XmlPullParserException {
      return mEventType;
    }

    /*package*/
    public boolean isWhitespace(String text)
        throws XmlPullParserException {
      if (text == null) {
        return false;
      }
      return text.split("\\s").length == 0;
    }

    @Override
    public boolean isWhitespace()
        throws XmlPullParserException {
      // Note: in android whitespaces are automatically stripped.
      // Here we have to skip them manually
      return isWhitespace(getText());
    }

    @Override
    public String getPrefix() {
      throw new RuntimeException("getPrefix not supported");
    }

    @Override
    public char[] getTextCharacters(int[] holderForStartAndLength) {
      String txt = getText();
      char[] chars = null;
      if (txt != null) {
        holderForStartAndLength[0] = 0;
        holderForStartAndLength[1] = txt.length();
        chars = new char[txt.length()];
        txt.getChars(0, txt.length(), chars, 0);
      }
      return chars;
    }

    @Override
    public String getNamespace() {
      String namespace = currentNode != null ? currentNode.getNamespaceURI() : null;
      if (namespace == null) {
        return "";
      }

      return maybeReplaceNamespace(namespace);
    }

    @Override
    public String getName() {
      if (currentNode == null) {
        return "";
      }
      return currentNode.getNodeName();
    }

    Node getAttributeAt(int index) {
      if (currentNode == null) {
        throw new IndexOutOfBoundsException(String.valueOf(index));
      }
      NamedNodeMap map = currentNode.getAttributes();
      if (index >= map.getLength()) {
        throw new IndexOutOfBoundsException(String.valueOf(index));
      }
      return map.item(index);
    }

    String getAttribute(String namespace, String name) {
      if (currentNode == null) {
        return null;
      }

      Element element = (Element) currentNode;
      if (element.hasAttributeNS(namespace, name)) {
        return element.getAttributeNS(namespace, name);
      } else if (applicationNamespace.equals(namespace)
          && element.hasAttributeNS(Attribute.RES_AUTO_NS_URI, name)) {
        return element.getAttributeNS(Attribute.RES_AUTO_NS_URI, name);
      }

      return null;
    }

    @Override
    public String getAttributeNamespace(int index) {
      Node attr = getAttributeAt(index);
      if (attr == null) {
        return null;
      }
      return maybeReplaceNamespace(attr.getNamespaceURI());
    }

    private String maybeReplaceNamespace(String namespace) {
      if (Attribute.RES_AUTO_NS_URI.equals(namespace)) {
        return applicationNamespace;
      } else {
        return namespace;
      }
    }

    @Override
    public String getAttributeName(int index) {
      try {
        Node attr = getAttributeAt(index);
        String namespace = maybeReplaceNamespace(attr.getNamespaceURI());
        return (Attribute.ANDROID_RES_NS_PREFIX + packageName).equals(namespace) ?
          attr.getLocalName() :
          attr.getNodeName();
      } catch (IndexOutOfBoundsException ex) {
        return null;
      }
    }

    @Override
    public String getAttributePrefix(int index) {
      throw new RuntimeException("getAttributePrefix not supported");
    }

    @Override
    public boolean isEmptyElementTag() throws XmlPullParserException {
      // In Android this method is left unimplemented.
      // This implementation is mirroring that.
      return false;
    }

    @Override
    public int getAttributeCount() {
      if (currentNode == null) {
        return -1;
      }
      return currentNode.getAttributes().getLength();
    }

    @Override
    public String getAttributeValue(int index) {
      return qualify(getAttributeAt(index).getNodeValue());
    }

    private String qualify(String value) {
      Attribute attribute = asAttribute(value);
      if (attribute == null) return null;
      return attribute.qualifiedValue();
    }

    private Attribute asAttribute(String value) {
      if (value == null) return null;
      return new Attribute(FAKE_RES_NAME, value, packageName);
    }

    @Override
    public String getAttributeType(int index) {
      // Android always returns CDATA even if the
      // node has no attribute.
      return "CDATA";
    }

    @Override
    public boolean isAttributeDefault(int index) {
      // The android implementation always returns false
      return false;
    }

    @Override
    public int nextToken() throws XmlPullParserException, IOException {
      return next();
    }

    @Override
    public String getAttributeValue(String namespace, String name) {
      return qualify(getAttribute(namespace, name));
    }

    @Override
    public int next() throws XmlPullParserException, IOException {
      if (!mStarted) {
        mStarted = true;
        return START_DOCUMENT;
      }
      if (mEventType == END_DOCUMENT) {
        return END_DOCUMENT;
      }
      int ev = nativeNext();
      if (mDecNextDepth) {
        mDepth--;
        mDecNextDepth = false;
      }
      switch (ev) {
        case START_TAG:
          mDepth++;
          break;
        case END_TAG:
          mDecNextDepth = true;
          break;
      }
      mEventType = ev;
      if (ev == END_DOCUMENT) {
        // Automatically close the parse when we reach the end of
        // a document, since the standard XmlPullParser interface
        // doesn't have such an API so most clients will leave us
        // dangling.
        close();
      }
      return ev;
    }

    /**
     * A twin implementation of the native android nativeNext(status)
     *
     * @throws XmlPullParserException
     */
    private int nativeNext() throws XmlPullParserException {
      switch (mEventType) {
        case (CDSECT): {
          throw new IllegalArgumentException(
              "CDSECT is not handled by Android");
        }
        case (COMMENT): {
          throw new IllegalArgumentException(
              "COMMENT is not handled by Android");
        }
        case (DOCDECL): {
          throw new IllegalArgumentException(
              "DOCDECL is not handled by Android");
        }
        case (ENTITY_REF): {
          throw new IllegalArgumentException(
              "ENTITY_REF is not handled by Android");
        }
        case (END_DOCUMENT): {
          // The end document event should have been filtered
          // from the invoker. This should never happen.
          throw new IllegalArgumentException(
              "END_DOCUMENT should not be found here.");
        }
        case (END_TAG): {
          return navigateToNextNode(currentNode);
        }
        case (IGNORABLE_WHITESPACE): {
          throw new IllegalArgumentException(
              "IGNORABLE_WHITESPACE");
        }
        case (PROCESSING_INSTRUCTION): {
          throw new IllegalArgumentException(
              "PROCESSING_INSTRUCTION");
        }
        case (START_DOCUMENT): {
          currentNode = document.getDocumentElement();
          return START_TAG;
        }
        case (START_TAG): {
          if (currentNode.hasChildNodes()) {
            // The node has children, navigate down
            return processNextNodeType(
                currentNode.getFirstChild());
          } else {
            // The node has no children
            return END_TAG;
          }
        }
        case (TEXT): {
          return navigateToNextNode(currentNode);
        }
        default: {
          // This can only happen if mEventType is
          // assigned with an unmapped integer.
          throw new RuntimeException(
              "Robolectric-> Uknown XML event type: " + mEventType);
        }
      }

    }

    /*protected*/ int processNextNodeType(Node node)
        throws XmlPullParserException {
      switch (node.getNodeType()) {
        case (Node.ATTRIBUTE_NODE): {
          throw new IllegalArgumentException("ATTRIBUTE_NODE");
        }
        case (Node.CDATA_SECTION_NODE): {
          return navigateToNextNode(node);
        }
        case (Node.COMMENT_NODE): {
          return navigateToNextNode(node);
        }
        case (Node.DOCUMENT_FRAGMENT_NODE): {
          throw new IllegalArgumentException("DOCUMENT_FRAGMENT_NODE");
        }
        case (Node.DOCUMENT_NODE): {
          throw new IllegalArgumentException("DOCUMENT_NODE");
        }
        case (Node.DOCUMENT_TYPE_NODE): {
          throw new IllegalArgumentException("DOCUMENT_TYPE_NODE");
        }
        case (Node.ELEMENT_NODE): {
          currentNode = node;
          return START_TAG;
        }
        case (Node.ENTITY_NODE): {
          throw new IllegalArgumentException("ENTITY_NODE");
        }
        case (Node.ENTITY_REFERENCE_NODE): {
          throw new IllegalArgumentException("ENTITY_REFERENCE_NODE");
        }
        case (Node.NOTATION_NODE): {
          throw new IllegalArgumentException("DOCUMENT_TYPE_NODE");
        }
        case (Node.PROCESSING_INSTRUCTION_NODE): {
          throw new IllegalArgumentException("DOCUMENT_TYPE_NODE");
        }
        case (Node.TEXT_NODE): {
          if (isWhitespace(node.getNodeValue())) {
            // Skip whitespaces
            return navigateToNextNode(node);
          } else {
            currentNode = node;
            return TEXT;
          }
        }
        default: {
          throw new RuntimeException(
              "Robolectric -> Unknown node type: " +
                  node.getNodeType() + ".");
        }
      }
    }

    /**
     * Navigate to the next node after a node and all of his
     * children have been explored.
     *
     * <p>If the node has unexplored siblings navigate to the
     * next sibling. Otherwise return to its parent.
     *
     * @param node the node which was just explored.
     * @return {@link XmlPullParserException#START_TAG} if the given
     *         node has siblings, {@link XmlPullParserException#END_TAG}
     *         if the node has no unexplored siblings or
     *         {@link XmlPullParserException#END_DOCUMENT} if the explored
     *         was the root document.
     * @throws XmlPullParserException if the parser fails to
     *                                parse the next node.
     */
    int navigateToNextNode(Node node)
        throws XmlPullParserException {
      Node nextNode = node.getNextSibling();
      if (nextNode != null) {
        // Move to the next siblings
        return processNextNodeType(nextNode);
      } else {
        // Goes back to the parent
        if (document.getDocumentElement().equals(node)) {
          currentNode = null;
          return END_DOCUMENT;
        }
        currentNode = node.getParentNode();
        return END_TAG;
      }
    }

    @Override
    public void require(int type, String namespace, String name)
        throws XmlPullParserException, IOException {
      if (type != getEventType()
          || (namespace != null && !namespace.equals(getNamespace()))
          || (name != null && !name.equals(getName()))) {
        throw new XmlPullParserException(
            "expected " + TYPES[type] + getPositionDescription());
      }
    }

    @Override
    public String nextText() throws XmlPullParserException, IOException {
      if (getEventType() != START_TAG) {
        throw new XmlPullParserException(
            getPositionDescription()
                + ": parser must be on START_TAG to read next text", this, null);
      }
      int eventType = next();
      if (eventType == TEXT) {
        String result = getText();
        eventType = next();
        if (eventType != END_TAG) {
          throw new XmlPullParserException(
              getPositionDescription()
                  + ": event TEXT it must be immediately followed by END_TAG", this, null);
        }
        return result;
      } else if (eventType == END_TAG) {
        return "";
      } else {
        throw new XmlPullParserException(
            getPositionDescription()
                + ": parser must be on START_TAG or TEXT to read text", this, null);
      }
    }

    @Override
    public int nextTag() throws XmlPullParserException, IOException {
      int eventType = next();
      if (eventType == TEXT && isWhitespace()) { // skip whitespace
        eventType = next();
      }
      if (eventType != START_TAG && eventType != END_TAG) {
        throw new XmlPullParserException(
            "Expected start or end tag. Found: " + eventType, this, null);
      }
      return eventType;
    }

    @Override
    public int getAttributeNameResource(int index) {
      return getResourceId(getAttributeName(index), packageName, "attr");
    }

    @Override
    public int getAttributeListValue(String namespace, String attribute,
        String[] options, int defaultValue) {
      String attr = getAttribute(namespace, attribute);
      if (attr == null) {
        return 0;
      }
      List<String> optList = Arrays.asList(options);
      int index = optList.indexOf(attr);
      if (index == -1) {
        return defaultValue;
      }
      return index;
    }

    @Override
    public boolean getAttributeBooleanValue(String namespace, String attribute,
        boolean defaultValue) {
      String attr = getAttribute(namespace, attribute);
      if (attr == null) {
        return defaultValue;
      }
      return Boolean.parseBoolean(attr);
    }

    @Override
    public int getAttributeResourceValue(String namespace, String attribute, int defaultValue) {
      String attr = getAttribute(namespace, attribute);
      if (attr != null && attr.startsWith("@")) {
        return getResourceId(attr.substring(1), packageName, null);
      }
      return defaultValue;
    }

    @Override
    public int getAttributeIntValue(String namespace, String attribute, int defaultValue) {
      String attr = getAttribute(namespace, attribute);
      if (attr == null) {
        return defaultValue;
      }
      try {
        return Integer.parseInt(attr);
      } catch (NumberFormatException ex) {
        return defaultValue;
      }
    }

    @Override
    public int getAttributeUnsignedIntValue(String namespace, String attribute, int defaultValue) {
      int value = getAttributeIntValue(namespace, attribute, defaultValue);
      if (value < 0) {
        return defaultValue;
      }
      return value;
    }

    @Override
    public float getAttributeFloatValue(String namespace, String attribute,
        float defaultValue) {
      String attr = getAttribute(namespace, attribute);
      if (attr == null) {
        return defaultValue;
      }
      try {
        return Float.parseFloat(attr);
      } catch (NumberFormatException ex) {
        return defaultValue;
      }
    }

    @Override
    public int getAttributeListValue(
        int idx, String[] options, int defaultValue) {
      try {
        String value = getAttributeValue(idx);
        List<String> optList = Arrays.asList(options);
        int index = optList.indexOf(value);
        if (index == -1) {
          return defaultValue;
        }
        return index;
      } catch (IndexOutOfBoundsException ex) {
        return defaultValue;
      }
    }

    @Override
    public boolean getAttributeBooleanValue(
        int idx, boolean defaultValue) {
      try {
        return Boolean.parseBoolean(getAttributeValue(idx));
      } catch (IndexOutOfBoundsException ex) {
        return defaultValue;
      }
    }

    @Override
    public int getAttributeResourceValue(int idx, int defaultValue) {
      String attributeValue = getAttributeValue(idx);
      if (attributeValue != null && attributeValue.startsWith("@")) {
        int resourceId = getResourceId(attributeValue.substring(1), packageName, null);
        if (resourceId != 0) {
          return resourceId;
        }
      }
      return defaultValue;
    }

    @Override
    public int getAttributeIntValue(int idx, int defaultValue) {
      try {
        return Integer.parseInt(getAttributeValue(idx));
      } catch (NumberFormatException ex) {
        return defaultValue;
      } catch (IndexOutOfBoundsException ex) {
        return defaultValue;
      }
    }

    @Override
    public int getAttributeUnsignedIntValue(int idx, int defaultValue) {
      int value = getAttributeIntValue(idx, defaultValue);
      if (value < 0) {
        return defaultValue;
      }
      return value;
    }

    @Override
    public float getAttributeFloatValue(int idx, float defaultValue) {
      try {
        return Float.parseFloat(getAttributeValue(idx));
      } catch (NumberFormatException ex) {
        return defaultValue;
      } catch (IndexOutOfBoundsException ex) {
        return defaultValue;
      }
    }

    @Override
    public String getIdAttribute() {
      return getAttribute(null, "id");
    }

    @Override
    public String getClassAttribute() {
      return getAttribute(null, "class");
    }

    @Override
    public int getIdAttributeResourceValue(int defaultValue) {
      String id = getIdAttribute();
      if (id == null) {
        return defaultValue;
      }
      try {
        return Integer.parseInt(id);
      } catch (NumberFormatException ex) {
        return defaultValue;
      }
    }

    @Override
    public int getStyleAttribute() {
      String attr = getAttribute(null, "style");
      if (attr == null) {
        return 0;
      }
      return getResourceId(attr, packageName, "style");
    }

    @Override
    public void close() {
      // Nothing to do
    }

    @Override
    protected void finalize() throws Throwable {
      close();
    }

    private int getResourceId(String possiblyQualifiedResourceName, String defaultPackageName, String defaultType) {
      Attribute attribute = asAttribute(possiblyQualifiedResourceName);

      if (attribute.isNull()) return 0;

      if (attribute.isStyleReference()) {
        Integer resourceId = resourceIndex.getResourceId(attribute.getStyleReference());
        return resourceId == null ? 0 : resourceId;
      }

      if (attribute.isResourceReference()) {
        Integer resourceId = resourceIndex.getResourceId(attribute.getResourceReference());
        return resourceId == null ? 0 : resourceId;
      }
      if (possiblyQualifiedResourceName.startsWith("@")) {
        possiblyQualifiedResourceName = possiblyQualifiedResourceName.substring(1);
      }
      ResName resName = ResName.qualifyResName(possiblyQualifiedResourceName, defaultPackageName, defaultType);
      Integer resourceId = resourceIndex.getResourceId(resName);
      return resourceId == null ? 0 : resourceId;
    }
  }
}
